Skip to content

feat(packaging): add Debian (.deb) build via nfpm with systemd unit (v2)#7583

Merged
JohnMcLear merged 15 commits intoether:developfrom
JohnMcLear:chore/packaging-apt-v2
Apr 27, 2026
Merged

feat(packaging): add Debian (.deb) build via nfpm with systemd unit (v2)#7583
JohnMcLear merged 15 commits intoether:developfrom
JohnMcLear:chore/packaging-apt-v2

Conversation

@JohnMcLear
Copy link
Copy Markdown
Member

@JohnMcLear JohnMcLear commented Apr 22, 2026

Summary

Reintroduces the Debian packaging from #7559 (reverted in #7582) with fixes for review feedback from #7559 and #7583:

  1. Rename etherpad-liteetherpad everywhere — package name, installed paths (/opt/etherpad, /etc/etherpad, /var/lib/etherpad, /var/log/etherpad), systemd unit (etherpad.service), CLI wrapper (/usr/bin/etherpad), Documentation= URL, and the .deb filename. Kept replaces/conflicts/provides: etherpad-lite so any dev builds upgrade cleanly.
  2. Default DB → sqlite (was dirty). postinstall rewrites the seeded /etc/etherpad/settings.json to dbType: "sqlite" pointed at /var/lib/etherpad/etherpad.db.
  3. /opt/etherpad/var/var/lib/etherpad/var symlink in postinstall. Etherpad writes runtime files (var/js, var/installed_plugins.json, …) under settings.root/var, which resolves to /opt/etherpad/var in this layout — ProtectSystem=strict blocks that. The symlink keeps the existing ReadWritePaths=/var/lib/etherpad sufficient and avoids opening /opt for writes. (Fixes Qodo bug "Readonly /opt breaks startup".)
  4. Seed installed_plugins.json in postinstall with {"plugins":[{"name":"ep_etherpad-lite","version":"<v>"}]}. Otherwise checkForMigration() falls back to pnpm ls on first boot, but pnpm is not a runtime dep — and the bundled node_modules already contains every shipped plugin, so no migration is needed. Also prevents accidental network plugin installs at first run. (Fixes Qodo bug "Plugin migration startup failure".)
  5. Verify nfpm .deb sha256 against upstream checksums.txt before sudo dpkg -i in CI. (Fixes Qodo bug "Unverified nfpm download".)
  6. CI runner Node 22 → 24 (current LTS, per @SamTV12345). The Depends: nodejs (>= 20) floor is unchanged — that mirrors Etherpad's engines.node.
  7. Stable etherpad-latest_<arch>.deb aliases attached to each GitHub Release, so users can curl …/releases/latest/download/etherpad-latest_amd64.deb without knowing the version. README updated.
  8. README refresh: drop stale 2.6.1 example, document the latest URL, point to setup_lts.x instead of pinning a NodeSource major.

What you get installing the package

  • /opt/etherpad with prebuilt, self-contained node_modules/ — no pnpm at runtime, only nodejs (>= 20).
  • etherpad system user/group, created via adduser in preinst.
  • /etc/etherpad/settings.json (seeded sqlite default; preserved across upgrades; removed on purge).
  • /var/lib/etherpad writable under ProtectSystem=strict, with var/ symlinked from /opt/etherpad/var so first-boot writes succeed.
  • /lib/systemd/system/etherpad.service — hardened unit (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, RestrictAddressFamilies) with Restart=on-failure.
  • /usr/bin/etherpad CLI wrapper running node --import tsx/esm.

CI smoke test

.github/workflows/deb-package.yml triggers on v* tags, builds amd64 + arm64 via native runners (ubuntu-latest + ubuntu-24.04-arm), and on amd64:

  1. Verifies nfpm checksum, then installs nfpm
  2. dpkg -i installs the Etherpad .deb
  3. Asserts /opt/etherpad/var symlinks to /var/lib/etherpad/var
  4. Asserts installed_plugins.json exists and lists ep_etherpad-lite
  5. Asserts /etc/etherpad/settings.json contains "dbType": "sqlite"
  6. systemctl start etherpad
  7. curl /health returns 200 (exits non-zero + dumps journalctl if not)
  8. dpkg --purge removes config + user

The release job attaches both etherpad_<ver>_<arch>.deb and etherpad-latest_<arch>.deb to the GitHub Release.

Not included (follow-up)

Publishing to an APT repo (Cloudsmith / Launchpad PPA / self-hosted reprepro) is out of scope — needs a governance decision on who holds the signing key. Recipes are in packaging/README.md.

Legacy

bin/buildDebian.sh and bin/deb-src/ are stale (Etherpad v1.3, init-based). Flagged for removal in a follow-up PR.

Test plan

  • pnpm install --frozen-lockfile && pnpm run build:etherpad succeeds
  • Local nfpm package --packager deb produces a well-formed .deb (dpkg-deb -I / -c)
  • Fresh install on Ubuntu 24.04: service starts, /health returns OK, /etc/etherpad/settings.json shows "dbType": "sqlite", /opt/etherpad/var → /var/lib/etherpad/var, installed_plugins.json is seeded, /var/lib/etherpad/etherpad.db is created by the etherpad user
  • Service starts cleanly without spawning pnpm (check journalctl -u etherpad for any pnpm references)
  • Upgrade install: /etc/etherpad/settings.json and installed_plugins.json untouched; service restarted
  • apt remove keeps /etc and /var/lib; apt purge removes them plus the etherpad user
  • CI workflow succeeds on amd64 and arm64; etherpad-latest_<arch>.deb aliases attached to release

Refs #7529, #7559, #7582

🤖 Generated with Claude Code

First-class Debian packaging for Etherpad, producing
etherpad_<version>_<arch>.deb artefacts for amd64 and arm64 from a
single nfpm manifest. Installing the package gives users:

- /opt/etherpad with a prebuilt, self-contained node_modules/ — no
  pnpm required at runtime, just `nodejs (>= 20)`.
- etherpad system user/group, created via `adduser` in preinst.
- /etc/etherpad/settings.json seeded from the template on first
  install, preserved across upgrades, removed on `purge`. Seed rewrites
  dbType from the template's dev-only `dirty` default to `sqlite`,
  pointed at /var/lib/etherpad/etherpad.db so fresh installs get an
  ACID-safe DB without manual config. sqlite is shipped by ueberdb2
  (rusty-store-kv), so no additional apt deps are needed.
- /var/lib/etherpad owned by etherpad:etherpad, writable under the
  hardened unit's ProtectSystem=strict.
- /lib/systemd/system/etherpad.service — hardened unit
  (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp,
  RestrictAddressFamilies) with Restart=on-failure.
- /usr/bin/etherpad CLI wrapper running `node --import tsx/esm`.

CI (.github/workflows/deb-package.yml) triggers on v* tags, builds both
arches via native runners (ubuntu-latest + ubuntu-24.04-arm),
smoke-tests the amd64 package end-to-end (install → verify sqlite
default → systemctl start → curl /health → purge → confirm user
removed), and attaches the artefacts to the GitHub Release.

Re-introduces the work from ether#7559 (reverted in ether#7582) with two
corrections:

1. Package name and all installed paths use `etherpad`, not
   `etherpad-lite` — matches the repo rename. Kept replaces/conflicts
   on `etherpad-lite` so any dev builds of the reverted PR upgrade
   cleanly.
2. Default dbType is `sqlite`, not `dirty`. The template's own comment
   says dirty is for testing only; shipping it by default to everyone
   who runs `apt install etherpad` is the wrong tradeoff for a
   production package.

Publishing to an APT repo (Cloudsmith, Launchpad PPA, self-hosted
reprepro) is intentionally out of scope — needs a governance decision
on who holds the signing key. Recipes are documented in
packaging/README.md.

Refs ether#7529, ether#7559, ether#7582

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 22, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (1)

Grey Divider


Action required

1. Settings.json clobbered 🐞 Bug ☼ Reliability ⭐ New
Description
packaging/scripts/postinstall.sh unconditionally force-replaces /opt/etherpad/settings.json with a
symlink to /etc/etherpad/settings.json, deleting any pre-existing non-symlink settings.json. This
can silently wipe an existing installation’s configuration (DB/auth/etc) when transitioning to the
.deb, potentially preventing correct startup.
Code

packaging/scripts/postinstall.sh[R40-42]

+    # Etherpad reads settings.json from CWD (/opt/etherpad). Expose
+    # the /etc copy there via symlink.
+    ln -sfn "${ACTIVE_SETTINGS}" "${APP_DIR}/settings.json"
Evidence
Etherpad’s default settings file path resolves to <etherpadRoot>/settings.json (typically
/opt/etherpad/settings.json), and the repo documentation/legacy init config explicitly points admins
at /opt/etherpad/settings.json. The postinstall script always runs ln -sfn to replace that file
with a symlink, and there is no guard/migration path for an existing regular file at that location.

packaging/scripts/postinstall.sh[40-42]
src/node/utils/Settings.ts[301-306]
src/node/utils/AbsolutePaths.ts[123-142]
README.md[264-275]
bin/deb-src/sysroot/etc/init/etherpad.conf[21-27]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The postinstall script forcefully replaces `/opt/etherpad/settings.json` with a symlink to `/etc/etherpad/settings.json`. If a user already has a real (non-symlink) `settings.json` at `/opt/etherpad/settings.json` (common for manual installs / legacy packaging), it will be deleted, losing configuration.

### Issue Context
Etherpad defaults to loading `settings.json` from the Etherpad root directory. The repo docs and legacy init config reference editing `/opt/etherpad/settings.json`, so it’s realistic that this file exists before installing the new .deb.

### Fix Focus Areas
- packaging/scripts/postinstall.sh[23-43]

### Suggested fix
Before `ln -sfn ... /opt/etherpad/settings.json`, add migration/backup logic:
- If `${APP_DIR}/settings.json` exists and is **not** a symlink:
 - If `${ACTIVE_SETTINGS}` does **not** exist yet, copy/move the existing `${APP_DIR}/settings.json` to `${ACTIVE_SETTINGS}` (preserving ownership/permissions appropriately), then proceed to symlink.
 - Else (both exist), keep `/etc` as source of truth but **back up** the old `${APP_DIR}/settings.json` to something like `${APP_DIR}/settings.json.bak.<timestamp>` and log a message to stdout/stderr.
- Only then run `ln -sfn`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Node before setup-node🐞 Bug ☼ Reliability
Description
In .github/workflows/deb-package.yml, the “Resolve version” step runs node -p ... before
actions/setup-node, so the workflow can fail on runners without a suitable preinstalled node
binary. This breaks packaging builds on non-tag pushes/PRs where VERSION is derived from
package.json.
Code

.github/workflows/deb-package.yml[R58-77]

+      - name: Resolve version
+        id: v
+        run: |
+          if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
+            VERSION="${GITHUB_REF_NAME#v}"
+          else
+            VERSION="$(node -p "require('./package.json').version")"
+          fi
+          echo "version=${VERSION}" >>"$GITHUB_OUTPUT"
+          echo "Packaging version: ${VERSION}"
+
+      - uses: pnpm/action-setup@v6
+        with:
+          version: 10
+
+      - name: Setup Node
+        uses: actions/setup-node@v6
+        with:
+          node-version: '24'
+          cache: pnpm
Evidence
The workflow calls node -p "require('./package.json').version" in the Resolve version step, but
Node is only explicitly installed later via actions/setup-node.

.github/workflows/deb-package.yml[58-77]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The workflow executes `node` to compute the version before `actions/setup-node` runs, which can fail on runners without a preinstalled (or compatible) Node.js.
### Issue Context
This affects non-tag builds where VERSION is read from `package.json`.
### Fix
Reorder steps so `actions/setup-node` runs before any `node` command, or avoid Node entirely in the version resolution step (e.g., parse JSON via `jq` or use a GitHub expression when possible).
### Fix Focus Areas
- .github/workflows/deb-package.yml[58-77]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Symlink chown escalation 🐞 Bug ⛨ Security
Description
packaging/scripts/postinstall.sh recursively chown -R's /var/lib/etherpad/var and
/var/lib/etherpad/plugin_packages after making them writable by the etherpad user, so a symlink
planted inside these trees can redirect root’s chown onto arbitrary paths during an upgrade. This
is a root privilege escalation vector triggered by dpkg running maintainer scripts as root.
Code

packaging/scripts/postinstall.sh[R63-90]

+    chown -R etherpad:etherpad "${RUNTIME_VAR}"
+
+    # Plugin install paths. Etherpad's admin UI installs plugins into
+    # ${root}/src/plugin_packages and creates symlinks under
+    # ${root}/src/node_modules. Both are under /opt and would EACCES
+    # under the etherpad user without these adjustments.
+    PLUGIN_PKG_LIVE=/var/lib/etherpad/plugin_packages
+    PLUGIN_PKG_LINK="${APP_DIR}/src/plugin_packages"
+    NODE_MODULES_DIR="${APP_DIR}/src/node_modules"
+
+    mkdir -p "${PLUGIN_PKG_LIVE}"
+    if [ -e "${PLUGIN_PKG_LINK}" ] && [ ! -L "${PLUGIN_PKG_LINK}" ]; then
+      cp -a "${PLUGIN_PKG_LINK}/." "${PLUGIN_PKG_LIVE}/" 2>/dev/null || true
+      rm -rf "${PLUGIN_PKG_LINK}"
+    fi
+    # chown after the cp -- cp -a preserves the (root) ownership of the
+    # staged source files and would re-root anything we chowned earlier.
+    chown -R etherpad:etherpad "${PLUGIN_PKG_LIVE}"
+    ln -sfn "${PLUGIN_PKG_LIVE}" "${PLUGIN_PKG_LINK}"
+
+    # node_modules is bundled (root-owned contents); the directory itself
+    # must be group-writable by etherpad so plugin installs can create
+    # symlinks alongside the shipped packages. ReadWritePaths in the unit
+    # also exposes it as writable under ProtectSystem=strict.
+    if [ -d "${NODE_MODULES_DIR}" ]; then
+      chgrp etherpad "${NODE_MODULES_DIR}"
+      chmod 2775 "${NODE_MODULES_DIR}"
+    fi
Evidence
The postinstall script first makes the runtime directories owned by etherpad, giving that
unprivileged account write access. Later in the same script it runs chown -R over those writable
trees; recursive chown typically dereferences symlinks, so symlinks created by etherpad can cause
root to change ownership of unintended targets on upgrade.

packaging/scripts/postinstall.sh[17-22]
packaging/scripts/postinstall.sh[63-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`postinstall.sh` runs `chown -R` on directories that are writable by the `etherpad` service user. If the `etherpad` user (or an attacker who gained that user) plants symlinks inside those directories, a future package upgrade can cause the root-run postinstall to `chown` arbitrary files/directories (symlink attack).
### Issue Context
This runs under `dpkg` as root on upgrades (`postinst configure`). The writable directories are `/var/lib/etherpad/var` and `/var/lib/etherpad/plugin_packages`.
### Fix Focus Areas
- packaging/scripts/postinstall.sh[63-90]
### Suggested fix
- Replace recursive `chown -R` calls with a symlink-safe alternative, such as:
- `chown -hR etherpad:etherpad "$RUNTIME_VAR"` and `chown -hR etherpad:etherpad "$PLUGIN_PKG_LIVE"` (GNU coreutils), and/or
- `find -P "$dir" -xdev -exec chown etherpad:etherpad {} +` to ensure symlinks are not dereferenced.
- Keep ownership correction behavior for normal files/dirs, but ensure symlinks cannot cause traversal or ownership changes outside the intended trees.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (8)
4. Smoke test doesn't ensure Node>=20🐞 Bug ☼ Reliability
Description
The deb-package workflow installs nodejs from the runner’s default APT sources without ensuring it
meets the package dependency nodejs (>= 20), so dpkg -i can fail to configure (or behave
differently) depending on the runner image. This makes the smoke-test nondeterministic and can
invalidate the subsequent assertions/startup test.
Code

.github/workflows/deb-package.yml[R136-139]

+          set -eux
+          sudo apt-get update
+          sudo apt-get install -y nodejs
+          sudo dpkg -i dist/*.deb || sudo apt-get install -f -y
Evidence
The package explicitly requires Node.js >= 20, but the workflow installs whatever `apt-get install
nodejs` provides. The local packaging smoke test script adds NodeSource first, indicating the
default distro repository may not be sufficient and that a dedicated setup step is expected for
determinism.

.github/workflows/deb-package.yml[133-140]
packaging/nfpm.yaml[22-26]
packaging/test-local.sh[120-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The CI smoke test installs `nodejs` from default APT sources without ensuring it satisfies the package dependency (`nodejs (>= 20)`), making the smoke test nondeterministic and potentially invalid.
### Issue Context
- The `.deb` declares `Depends: nodejs (>= 20)`.
- The workflow currently does `apt-get install -y nodejs` with no repo setup/pinning.
- `packaging/test-local.sh` already uses NodeSource (`setup_lts.x`) before installing nodejs.
### Fix Focus Areas
- .github/workflows/deb-package.yml[136-139]
### Suggested fix
In the smoke-test step, install Node.js via a deterministic mechanism, for example:
- Add NodeSource LTS repo (`curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -`) then `sudo apt-get install -y nodejs`, **or**
- Use a pinned distro/repo that guarantees `nodejs >= 20`.
This should happen before `dpkg -i dist/*.deb` so the package can configure and `postinstall` assertions are meaningful.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. run_cmd spawn error untested 📘 Rule violation ☼ Reliability
Description
The PR fixes run_cmd to reject on spawn errors (e.g. missing binary), but it does not add a
regression test to ensure this behavior doesn’t silently regress. Without an automated test, the
original process-killing failure mode could be reintroduced unnoticed.
Code

src/node/utils/run_cmd.ts[R149-159]

+  // Without this, a spawn failure (e.g. ENOENT for a missing binary) is
+  // emitted as an 'error' event with no listener, which Node.js treats as
+  // an uncaught exception that bypasses any try/catch around the awaited
+  // promise and kills the process. Reject the promise instead so callers
+  // can handle it.
+  proc.on('error', (err) => {
+    procFailedErr.message = `Failed to spawn ${args[0]}: ${(err as Error).message}`;
+    procFailedErr.code = (err as any).code;
+    logger.debug(procFailedErr.stack);
+    px.reject(procFailedErr);
+  });
Evidence
Compliance ID 1 requires bug fixes to include a regression test. The added proc.on('error', ...)
handler is explicitly a bug fix for unhandled spawn errors, but no corresponding test is
added/modified in this PR diff to validate the new rejection behavior.

src/node/utils/run_cmd.ts[149-159]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A bug fix was added to `src/node/utils/run_cmd.ts` to handle `spawn` failures by rejecting the returned promise, but there is no regression test that would fail if this handler were removed.
## Issue Context
The new code handles the `ChildProcess` `error` event (e.g. `ENOENT`) and calls `px.reject(...)` instead of letting Node treat the unhandled `error` event as an uncaught exception that kills the process. Add a `vitest` test that calls `run_cmd()` with a definitely-missing binary name and asserts it rejects with an error that includes `code === 'ENOENT'` (or equivalent), ensuring the test suite would fail/crash if the handler were reverted.
## Fix Focus Areas
- src/node/utils/run_cmd.ts[149-159]
- src/tests/backend-new/specs/[add new test file exercising spawn ENOENT]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Preinst depends not guaranteed🐞 Bug ☼ Reliability
Description
The preinstall maintainer script requires addgroup/adduser, but the package only declares adduser as
a regular dependency, so dpkg -i on a minimal system can fail before unpacking if adduser isn’t
already installed.
Code

packaging/scripts/preinstall.sh[R6-18]

+case "$1" in
+  install|upgrade)
+    if ! getent group etherpad >/dev/null 2>&1; then
+      addgroup --system etherpad
+    fi
+    if ! getent passwd etherpad >/dev/null 2>&1; then
+      adduser --system --ingroup etherpad \
+              --home /var/lib/etherpad \
+              --no-create-home \
+              --shell /usr/sbin/nologin \
+              --gecos "Etherpad service user" \
+              etherpad
+    fi
Evidence
preinstall.sh unconditionally executes addgroup/adduser under set -e. nfpm.yaml declares adduser
under depends, which is not sufficient to guarantee the binary exists before preinst runs in
direct dpkg -i flows (where dependencies are not pre-fetched/configured automatically).

packaging/scripts/preinstall.sh[1-18]
packaging/nfpm.yaml[22-26]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`preinstall.sh` calls `addgroup`/`adduser` during the `preinst` phase, but `adduser` is only in `Depends`. Direct installs via `dpkg -i` can run `preinst` before `adduser` is present, causing installation to abort.
### Issue Context
This is especially problematic because a `preinst` failure happens *before* unpacking, so follow-up dependency repair (`apt-get -f install`) may not be able to recover.
### Fix Focus Areas
Implement one of:
- **Debian-correct dependency**: add a Debian `Pre-Depends: adduser` (if nfpm supports it) so `adduser` is guaranteed before `preinst`.
- **Move user/group creation later**: shift user/group creation to `postinstall` (`configure`), and adjust packaging so unpack-time ownership does not require the `etherpad` group to already exist (e.g., set `/etc/default/etherpad` group to root in the payload and `chgrp` it in `postinstall`).
- packaging/scripts/preinstall.sh[1-18]
- packaging/nfpm.yaml[22-26]
- packaging/scripts/postinstall.sh[16-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Wrong tsx loader mode🐞 Bug ≡ Correctness
Description
packaging/bin/etherpad starts Etherpad with Node’s ESM preload (--import …/tsx/…/esm/index.mjs), but
Etherpad’s production entrypoint expects the CommonJS tsx/cjs require hook and uses CommonJS
exports. This will likely crash at startup (exports is undefined) and prevent etherpad.service from
starting.
Code

packaging/bin/etherpad[R13-17]

+# Run the server through tsx's ESM loader (shipped in node_modules).
+# No pnpm needed at runtime.
+exec node \
+    --import "file://${APP_DIR}/src/node_modules/tsx/dist/esm/index.mjs" \
+    "${APP_DIR}/src/node/server.ts" \
Evidence
The wrapper preloads tsx’s ESM loader, but Etherpad’s own prod script uses tsx/cjs, and the server
entrypoint uses CommonJS-style exports (which are not available in ESM).

packaging/bin/etherpad[13-18]
src/package.json[144-151]
src/node/server.ts[107-116]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`/usr/bin/etherpad` preloads tsx via Node's `--import` pointing at tsx's ESM loader, but Etherpad's server entrypoint is started in CommonJS mode (`tsx/cjs`) and uses `exports.*`. This mismatch can break startup.
### Issue Context
Etherpad's own `src/package.json` uses `node --require tsx/cjs node/server.ts` for production. `src/node/server.ts` assigns `exports.start = ...`, which will throw if the file is evaluated as an ES module.
### Fix Focus Areas
- packaging/bin/etherpad[13-18]
### Suggested change
Switch to the CJS preload used by Etherpad:
- Replace `--import "file://.../tsx/.../esm/index.mjs"` with `--require tsx/cjs` (or an absolute path to `tsx/cjs` under the installed tree).
- Keep `NODE_ENV=production` as you already do.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Plugins not installable🐞 Bug ≡ Correctness
Description
The package installs /opt/etherpad as root:root and the systemd unit uses ProtectSystem=strict
without making Etherpad’s plugin install paths writable. Etherpad installs plugins into
/opt/etherpad/src/plugin_packages and creates symlinks under /opt/etherpad/src/node_modules, so
plugin installs/upgrades will fail with EACCES under the packaged service.
Code

packaging/systemd/etherpad.service[R23-44]

+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectKernelLogs=true
+ProtectControlGroups=true
+ProtectHostname=true
+ProtectClock=true
+RestrictRealtime=true
+RestrictSUIDSGID=true
+RestrictNamespaces=true
+LockPersonality=true
+MemoryDenyWriteExecute=false     # Node's JIT needs W+X mappings
+SystemCallArchitectures=native
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
+UMask=0027
+
+ReadWritePaths=/var/lib/etherpad /var/log/etherpad /etc/etherpad
+
Evidence
Etherpad’s plugin framework writes to settings.root/src/plugin_packages and creates symlinks in
settings.root/src/node_modules, but the package makes /opt/etherpad root-owned and the systemd
sandbox only allows writes to /var/lib/etherpad, /var/log/etherpad, and /etc/etherpad. The
postinstall script only redirects /opt/etherpad/var to /var/lib and does not address plugin paths.

packaging/nfpm.yaml[51-59]
packaging/systemd/etherpad.service[23-44]
src/node/utils/Settings.ts[301-305]
src/static/js/pluginfw/installer.ts[25-29]
src/static/js/pluginfw/LinkInstaller.ts[18-24]
src/static/js/pluginfw/LinkInstaller.ts[203-214]
packaging/scripts/postinstall.sh[40-50]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The packaged service cannot install/upgrade plugins at runtime because Etherpad's plugin manager writes to:
- `${settings.root}/src/plugin_packages`
- `${settings.root}/src/node_modules` (creates symlinks)
In the package, `${settings.root}` resolves to `/opt/etherpad`, which is installed as root:root and is read-only under `ProtectSystem=strict`.
### Issue Context
- `installer.ts` defines `pluginInstallPath` under `settings.root/src/plugin_packages` and `node_modules` under `.../src/node_modules`.
- `LinkInstaller` uses `PluginManager({pluginsPath: pluginInstallPath})` and symlinks into `node_modules`.
- `nfpm.yaml` sets `/opt/etherpad` tree owner/group to root:root.
- `etherpad.service` only allows writes to `/var/lib/etherpad`, `/var/log/etherpad`, `/etc/etherpad`.
- `postinstall.sh` currently only redirects `/opt/etherpad/var`.
### Fix Focus Areas
- packaging/systemd/etherpad.service[23-44]
- packaging/nfpm.yaml[51-59]
- packaging/scripts/postinstall.sh[40-50]
### Suggested fix approach
1) Redirect plugin storage to `/var/lib/etherpad` (similar to the existing `/opt/etherpad/var` redirect):
- Create `/var/lib/etherpad/plugin_packages` owned by `etherpad:etherpad`.
- Symlink `/opt/etherpad/src/plugin_packages -> /var/lib/etherpad/plugin_packages` in `postinstall.sh`.
2) Allow symlink creation under `/opt/etherpad/src/node_modules`:
- Add `/opt/etherpad/src/node_modules` to `ReadWritePaths=` in the unit, and
- In `postinstall.sh`, make that directory writable for the service user (e.g., `chgrp etherpad /opt/etherpad/src/node_modules && chmod g+w /opt/etherpad/src/node_modules`).
If runtime plugin installation is intentionally unsupported for the .deb, then explicitly disable/hide the plugin installer UI and document the limitation; otherwise users will hit runtime failures.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Broken tag trigger globs🐞 Bug ☼ Reliability
Description
The deb-package workflow’s tag filters use regex-like patterns (v[0-9]+.[0-9]+.[0-9]+) but GitHub
Actions uses glob matching, where [0-9]+ is a single-character class (digit or '+'), not
“one-or-more digits”. As a result, common tags like v2.10.0 will not match and the workflow won’t
run.
Code

.github/workflows/deb-package.yml[R2-6]

+on:
+  push:
+    tags:
+      - 'v[0-9]+.[0-9]+.[0-9]+'
+      - 'v[0-9]+.[0-9]+.[0-9]+-*'
Evidence
The new workflow uses [0-9]+ sequences in the tag filters, but the repository’s existing release
workflow uses a standard glob (v*.*.*) which correctly matches multi-digit semver components. With
glob semantics, [0-9]+ matches exactly one character, so multi-digit segments (like 10) won’t
match.

.github/workflows/deb-package.yml[2-7]
.github/workflows/handleRelease.yml[4-8]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The workflow tag filters are written like regex (`[0-9]+`), but Actions tag filters are globs. In a glob, `[0-9]+` matches a single character (a digit or '+'), so tags with multi-digit components (e.g., `v2.10.0`) won't trigger the workflow.
### Issue Context
There is already a correct pattern in `.github/workflows/handleRelease.yml`: `v*.*.*`.
### Fix Focus Areas
- .github/workflows/deb-package.yml[2-7]
### Suggested change
Replace the tag filters with glob patterns such as:
- `v*.*.*`
- `v*.*.*-*`
(or a single broader `v*` if you prefer and then validate/tag-parse inside the job).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Plugin migration startup failure🐞 Bug ☼ Reliability
Description
On fresh installs, Etherpad startup runs checkForMigration(), which falls back to running pnpm ls
and then writing /opt/etherpad/var/installed_plugins.json if the file is missing; the package does
not ship/create that file, and the systemd unit disallows writes under /opt/etherpad. This can
cause the service to crash during startup and never reach /health.
Code

.github/workflows/deb-package.yml[R73-85]

+      - name: Stage tree for packaging
+        run: |
+          set -eux
+          STAGE=staging/opt/etherpad
+          mkdir -p "${STAGE}"
+
+          # Production footprint = src/ + bin/ + node_modules/ + metadata.
+          cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE \
+                node_modules "${STAGE}/"
+
+          # Make pnpm-workspace.yaml production-only (same trick Dockerfile uses).
+          printf 'packages:\n  - src\n  - bin\n' > "${STAGE}/pnpm-workspace.yaml"
+
Evidence
The packaging workflow stages only src/, bin/, root metadata, and node_modules/ into the
/opt/etherpad tree, so /opt/etherpad/var/installed_plugins.json is not present in the resulting
package. At runtime, Etherpad’s server startup always calls checkForMigration(), which runs `pnpm
ls if installed_plugins.json is missing and then writes the file under settings.root/var`; with
the packaged layout settings.root resolves to /opt/etherpad. The systemd unit uses
ProtectSystem=strict and only whitelists /var/lib/etherpad, /var/log/etherpad, and
/etc/etherpad as writable, so writing to /opt/etherpad/var is not permitted even if the
directory existed.

.github/workflows/deb-package.yml[73-85]
packaging/nfpm.yaml[47-59]
packaging/scripts/postinstall.sh[14-40]
packaging/systemd/etherpad.service[22-44]
packaging/bin/etherpad[13-18]
src/node/server.ts[151-176]
src/static/js/pluginfw/installer.ts[25-90]
src/static/js/pluginfw/installer.ts[122-148]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Fresh installs can fail because Etherpad calls `checkForMigration()` during startup, and if `/opt/etherpad/var/installed_plugins.json` is missing it tries to execute `pnpm ls` and write that file under `/opt/etherpad/var`. The .deb packaging currently doesn’t ship/create that file (or even `/opt/etherpad/var`), and the systemd unit doesn’t permit writes under `/opt/etherpad`.
### Issue Context
- Packaging stages `/opt/etherpad` from a curated file list.
- Etherpad’s plugin migration logic treats absence of `var/installed_plugins.json` as a trigger to run `pnpm` and then persist plugin state under `settings.root/var`.
- The systemd sandbox only allows writes to `/var/lib/etherpad`, `/var/log/etherpad`, and `/etc/etherpad`.
### Fix Focus Areas
- .github/workflows/deb-package.yml[73-85]
- packaging/nfpm.yaml[47-59]
- packaging/scripts/postinstall.sh[14-40]
- packaging/systemd/etherpad.service[22-44]
### Suggested fix approach
1. Ensure `/opt/etherpad/var/` exists in the packaged tree (or create it in `postinstall.sh`).
2. Seed `/opt/etherpad/var/installed_plugins.json` at install time with minimal valid content (for example, only `ep_etherpad-lite`), so `checkForMigration()` does not attempt to run `pnpm` on first boot.
3. If you want runtime plugin install/uninstall to work under the hardened unit, relocate plugin state to `/var/lib/etherpad` (and symlink `/opt/etherpad/var` to it), and/or extend `ReadWritePaths` to include the required writable plugin directories.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Readonly /opt breaks startup🐞 Bug ☼ Reliability
Description
The unit sets ProtectSystem=strict but Etherpad writes runtime files under settings.root/var during
startup (for example var/js and var/installed_plugins.json). Because /opt/etherpad/var is neither
created/redirected in postinstall nor included in ReadWritePaths, a fresh install can fail to start
with mkdir/write errors under /opt/etherpad/var.
Code

packaging/systemd/etherpad.service[R22-44]

+# --- Sandboxing ---------------------------------------------------------
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectKernelLogs=true
+ProtectControlGroups=true
+ProtectHostname=true
+ProtectClock=true
+RestrictRealtime=true
+RestrictSUIDSGID=true
+RestrictNamespaces=true
+LockPersonality=true
+MemoryDenyWriteExecute=false     # Node's JIT needs W+X mappings
+SystemCallArchitectures=native
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
+UMask=0027
+
+ReadWritePaths=/var/lib/etherpad /var/log/etherpad /etc/etherpad
+
Evidence
The service sandbox makes the filesystem read-only except for explicit exceptions, but Etherpad’s
startup path writes to <root>/var. With the Debian layout, Etherpad’s computed root is the directory
above src (so /opt/etherpad), making those writes target /opt/etherpad/var which is not writable per
the unit and not redirected by the packaging scripts.

packaging/systemd/etherpad.service[22-44]
packaging/scripts/postinstall.sh[34-40]
src/node/utils/AbsolutePaths.ts[77-93]
src/node/server.ts[173-175]
src/static/js/pluginfw/installer.ts[25-29]
src/static/js/pluginfw/installer.ts[136-148]
src/node/hooks/express/specialpages.ts[310-314]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The systemd unit makes `/opt/etherpad` effectively read-only (`ProtectSystem=strict` + `ReadWritePaths` omits `/opt/etherpad/...`), but Etherpad writes to `path.join(settings.root, 'var', ...)` during startup, where `settings.root` resolves to `/opt/etherpad` in this package layout. This can prevent `etherpad.service` from starting.
### Issue Context
Etherpad startup awaits `checkForMigration()` which can write `${root}/var/installed_plugins.json`, and the express hooks create `${root}/var/js`. The package currently does not create or redirect `/opt/etherpad/var` to a writable location.
### Fix Focus Areas
- packaging/scripts/postinstall.sh[34-40]
- packaging/systemd/etherpad.service[22-44]
### Suggested fix
Implement one of the following (prefer the symlink approach to keep writes out of `/opt`):
1) **Symlink `/opt/etherpad/var` to a writable location**:
- In `postinstall.sh` (configure):
- `mkdir -p /var/lib/etherpad/var`
- `ln -sfn /var/lib/etherpad/var /opt/etherpad/var`
- `chown -R etherpad:etherpad /var/lib/etherpad/var`
- This keeps `ReadWritePaths=/var/lib/etherpad` sufficient.
2) **Allow `/opt/etherpad/var` writes explicitly**:
- Create `/opt/etherpad/var` in `postinstall.sh` and `chown` it to `etherpad:etherpad`.
- Add `/opt/etherpad/var` to `ReadWritePaths` in the unit.
Either way, ensure the directory exists before service start so `fs.mkdirSync(.../var/js)` and plugin migration writes don’t throw.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

12. Local build docs break 🐞 Bug ⚙ Maintainability ⭐ New
Description
packaging/README.md’s local build instructions copy to packaging/etc/settings.json.dist without
creating packaging/etc first. On a fresh checkout (where packaging/etc does not exist), this causes
the documented local build steps to fail.
Code

packaging/README.md[R28-35]

+# Stage the tree the way CI does:
+STAGE=staging/opt/etherpad
+mkdir -p "$STAGE"
+cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE \
+      node_modules "$STAGE/"
+printf 'packages:\n  - src\n  - bin\n' > "$STAGE/pnpm-workspace.yaml"
+cp settings.json.template packaging/etc/settings.json.dist
+
Evidence
The README instructs copying into packaging/etc but does not create that directory. The provided
local smoke-test script explicitly creates packaging/etc before doing the same copy, indicating the
README is missing a required step.

packaging/README.md[28-35]
packaging/test-local.sh[37-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The documented local build steps fail because `packaging/etc/` is not created before copying `settings.json.template` into it.

### Issue Context
`packaging/test-local.sh` already creates `packaging/etc`, so the README should match.

### Fix Focus Areas
- packaging/README.md[28-35]

### Suggested fix
Insert a `mkdir -p packaging/etc` (or include it in an existing mkdir command) before:
```sh
cp settings.json.template packaging/etc/settings.json.dist
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Overbroad workflow token scope🐞 Bug ⛨ Security
Description
The deb-package workflow grants contents: write at the workflow level, so the build job also gets
write permissions even on pull_request runs, despite only the release job needing to upload release
assets.
Code

.github/workflows/deb-package.yml[R34-36]

+permissions:
+  contents: write    # attach release assets
+
Evidence
Workflow-level permissions apply to all jobs unless overridden; here the build job does not need
write access, while the release job is the only one that attaches assets (and already declares
contents: write).

.github/workflows/deb-package.yml[34-36]
.github/workflows/deb-package.yml[40-194]
.github/workflows/deb-package.yml[195-202]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`.github/workflows/deb-package.yml` sets `permissions: contents: write` at the workflow level, unnecessarily broadening the GITHUB_TOKEN scope for the `build` job (including pull_request runs).
### Issue Context
Only the `release` job needs to write release assets. The `build` job checks out code, builds packages, and uploads artifacts.
### Fix Focus Areas
- .github/workflows/deb-package.yml[34-36]
- .github/workflows/deb-package.yml[40-194]
- .github/workflows/deb-package.yml[195-202]
### Suggested fix
- Change workflow-level permissions to read-only (or omit entirely).
- Keep/add `permissions: contents: write` only on the `release` job.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. Service stays enabled after removal🐞 Bug ☼ Reliability
Description
packaging/scripts/postinstall.sh enables etherpad.service on first install, but
packaging/scripts/postremove.sh never disables it on remove/purge, which can leave an enabled
systemd symlink pointing at a removed unit file after uninstall.
Code

packaging/scripts/postinstall.sh[R92-103]

+    if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
+      systemctl daemon-reload || true
+      # Enable on first install; leave state alone on upgrade.
+      if [ -z "$2" ]; then
+        systemctl enable etherpad.service >/dev/null 2>&1 || true
+      fi
+      # Restart on upgrade to pick up new code (skip on fresh install --
+      # admin may want to configure first).
+      if [ -n "$2" ]; then
+        systemctl try-restart etherpad.service >/dev/null 2>&1 || true
+      fi
+    fi
Evidence
The postinstall script enables the unit on fresh install, but the postremove script only
daemon-reloads and never disables the unit, so systemd enablement state is not cleaned up during
uninstall.

packaging/scripts/postinstall.sh[92-103]
packaging/scripts/postremove.sh[9-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The package enables `etherpad.service` on first install but does not disable it on `remove`/`purge`, which can leave stale enablement symlinks under `/etc/systemd/system/*` after uninstall.
### Issue Context
- `postinstall.sh` runs `systemctl enable etherpad.service` on fresh installs.
- `postremove.sh` only runs `systemctl daemon-reload` and removes a few symlinks under `/opt/etherpad`, but never disables the unit.
### Fix Focus Areas
- packaging/scripts/postinstall.sh[92-103]
- packaging/scripts/preremove.sh[6-11]
- packaging/scripts/postremove.sh[9-34]
### Suggested change
- Add `systemctl disable etherpad.service` (and optionally `systemctl reset-failed etherpad.service`) during uninstall. The safest place is `preremove.sh` (before the unit file is removed), and/or in `postremove.sh` for both `remove` and `purge` as a fallback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (4)
15. Purge skips symlink cleanup🐞 Bug ≡ Correctness
Description
packaging/scripts/postremove.sh removes the /opt/etherpad symlinks only in the remove) case,
not in the purge) case. If postrm purge runs without the remove cleanup, /opt/etherpad can
be left behind containing dangling symlinks created by postinstall.sh.
Code

packaging/scripts/postremove.sh[R19-34]

+  purge)
+    rm -rf /etc/etherpad
+    rm -rf /var/lib/etherpad
+    rm -rf /var/log/etherpad
+
+    if getent passwd etherpad >/dev/null 2>&1; then
+      deluser --system etherpad >/dev/null 2>&1 || true
+    fi
+    if getent group etherpad >/dev/null 2>&1; then
+      delgroup --system etherpad >/dev/null 2>&1 || true
+    fi
+
+    if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
+      systemctl daemon-reload || true
+    fi
+    ;;
Evidence
postinstall.sh creates symlinks under /opt/etherpad (settings.json, var, and
src/plugin_packages). postremove.sh deletes those symlinks only for remove), but purge) only
deletes /etc and /var data and the user/group, leaving the symlinks unhandled in that branch.

packaging/scripts/postinstall.sh[36-50]
packaging/scripts/postinstall.sh[65-82]
packaging/scripts/postremove.sh[9-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The `purge)` branch does not remove `/opt/etherpad` symlinks created during install, which can leave `/opt/etherpad` non-empty (and symlinks dangling).
### Issue Context
Symlinks are created in postinstall to expose `/etc/etherpad/settings.json` and redirect writable paths.
### Fix
In the `purge)` case, also remove the same symlinks as in `remove)` (or remove `/opt/etherpad` entirely if appropriate and safe).
### Fix Focus Areas
- packaging/scripts/postremove.sh[9-34]
- packaging/scripts/postinstall.sh[36-50]
- packaging/scripts/postinstall.sh[65-82]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. curl|sudo bash in CI🐞 Bug ⛨ Security
Description
The Debian smoke test installs NodeSource by piping a remote script directly into sudo bash,
executing network-fetched content as root during the workflow run. This increases the blast radius
of a supply-chain compromise of that endpoint to the job that builds and uploads release artifacts.
Code

.github/workflows/deb-package.yml[R141-142]

+          curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
+          sudo apt-get install -y nodejs
Evidence
The workflow explicitly downloads and executes a remote script as root via a pipe, without any
in-workflow signature/fingerprint verification step.

.github/workflows/deb-package.yml[133-143]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The workflow executes `curl ... | sudo bash -` to install Node.js, which runs network content as root.
### Issue Context
This happens in the smoke test job before installing the newly built `.deb`.
### Fix Focus Areas
- .github/workflows/deb-package.yml[133-143]
### Suggested fix
Prefer a repository-based installation with explicit keyring + signed-by configuration (or a pinned, checksum-verified installer artifact) rather than piping to `bash`. For example:
- Fetch the NodeSource GPG key into `/usr/share/keyrings/nodesource.gpg` and configure the apt source with `signed-by=...`, then `apt-get update && apt-get install nodejs`.
- Alternatively, run the smoke test in a container image that already contains Node >=20 (to avoid installing Node via shell pipe at all).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


17. RPM scripts use adduser🐞 Bug ≡ Correctness
Description
The nfpm manifest claims the same packaging manifest can produce RPMs (and includes rpm-specific
dependency overrides), but the install/remove scripts call Debian-only user/group management
commands (addgroup/adduser/deluser/delgroup), so an RPM install would fail at script execution time.
Code

packaging/nfpm.yaml[R104-120]

+scripts:
+  preinstall:  ./packaging/scripts/preinstall.sh
+  postinstall: ./packaging/scripts/postinstall.sh
+  preremove:   ./packaging/scripts/preremove.sh
+  postremove:  ./packaging/scripts/postremove.sh
+
+overrides:
+  deb:
+    depends:
+      - nodejs (>= 20)
+      - adduser
+      - ca-certificates
+  rpm:
+    depends:
+      - nodejs >= 20
+      - shadow-utils
+      - ca-certificates
Evidence
The repo explicitly documents multi-packager output and configures rpm dependencies, but the
referenced scripts use Debian-specific commands and there are no rpm-specific script overrides,
creating an internal inconsistency that breaks RPM installation.

packaging/README.md[1-5]
packaging/nfpm.yaml[104-120]
packaging/scripts/preinstall.sh[6-18]
packaging/scripts/postremove.sh[19-30]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`packaging/nfpm.yaml` declares RPM support (and RPM dependencies), but the lifecycle scripts are Debian-specific (`addgroup/adduser/deluser/delgroup`). If someone builds an RPM from this manifest, installation/removal will fail when those commands are missing.
### Issue Context
The README also states the same manifest produces `.rpm`/`.apk`, which further implies this path is intended to work.
### Fix Focus Areas
- packaging/nfpm.yaml[104-120]
- packaging/scripts/preinstall.sh[6-18]
- packaging/scripts/postremove.sh[19-30]
- packaging/README.md[1-5]
### Suggested fix options
1) **If RPM/APK is in-scope:**
- Add per-packager script overrides (RPM equivalents using `groupadd/useradd/userdel/groupdel` from `shadow-utils`), and adjust systemd unit install path if needed for RPM-based distros.
2) **If Debian-only for now:**
- Remove/disable the RPM/APK claims in `packaging/README.md` and remove the `overrides.rpm` block (or clearly document Debian-only support) to avoid shipping a manifest that advertises broken RPM support.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. nfpm download not verified 🐞 Bug ⛨ Security
Description
The release workflow downloads an nfpm .deb via curl and installs it with dpkg without verifying a
checksum or signature. If the upstream artifact is tampered with, the workflow could produce
compromised release packages.
Code

.github/workflows/deb-package.yml[R64-71]

+      - name: Install nfpm
+        run: |
+          set -e
+          NFPM_ARCH=amd64
+          [ "${{ matrix.arch }}" = "arm64" ] && NFPM_ARCH=arm64
+          curl -fsSL -o /tmp/nfpm.deb \
+            "https://github.com/goreleaser/nfpm/releases/download/${NFPM_VERSION}/nfpm_${NFPM_VERSION#v}_${NFPM_ARCH}.deb"
+          sudo dpkg -i /tmp/nfpm.deb
Evidence
The workflow directly installs a remotely fetched .deb into the runner without any integrity
verification step (checksum/GPG).

.github/workflows/deb-package.yml[64-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The workflow installs nfpm from a downloaded `.deb` without any integrity verification.
### Issue Context
Even with HTTPS, this lacks defense-in-depth against compromised upstream release artifacts.
### Fix Focus Areas
- .github/workflows/deb-package.yml[64-71]
### Suggested fix
Add an integrity verification step before `dpkg -i`, for example:
- Download nfpm’s published `checksums.txt` (or equivalent) for `${NFPM_VERSION}` and verify `/tmp/nfpm.deb` with `sha256sum -c`.
- Alternatively, verify a published signature (GPG/cosign) if available for nfpm release artifacts.
- Only proceed to `sudo dpkg -i` if verification passes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 287497b

Results up to commit 1173fcb


🐞 Bugs (4) 📘 Rule violations (1)


Action required
1. Node before setup-node🐞 Bug ☼ Reliability
Description
In .github/workflows/deb-package.yml, the “Resolve version” step runs node -p ... before
actions/setup-node, so the workflow can fail on runners without a suitable preinstalled node
binary. This breaks packaging builds on non-tag pushes/PRs where VERSION is derived from
package.json.
Code

.github/workflows/deb-package.yml[R58-77]

+      - name: Resolve version
+        id: v
+        run: |
+          if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
+            VERSION="${GITHUB_REF_NAME#v}"
+          else
+            VERSION="$(node -p "require('./package.json').version")"
+          fi
+          echo "version=${VERSION}" >>"$GITHUB_OUTPUT"
+          echo "Packaging version: ${VERSION}"
+
+      - uses: pnpm/action-setup@v6
+        with:
+          version: 10
+
+      - name: Setup Node
+        uses: actions/setup-node@v6
+        with:
+          node-version: '24'
+          cache: pnpm
Evidence
The workflow calls node -p &quo...

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Add Debian (.deb) packaging with systemd service and nfpm manifest

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add native Debian (.deb) packaging via nfpm for amd64 and arm64
• Configure systemd service with hardened security settings and auto-restart
• Implement pre/post install/remove scripts for user/group and config management
• Default database to sqlite instead of dev-only dirty mode for production safety
• Add CI workflow to build, smoke-test, and attach packages to GitHub releases
Diagram
flowchart LR
  A["Source code<br/>+ node_modules"] -->|Stage| B["staging/opt/etherpad"]
  B -->|nfpm manifest| C["nfpm.yaml"]
  C -->|Build| D["etherpad_VERSION_ARCH.deb"]
  E["preinstall.sh"] -->|Create user| F["etherpad system user"]
  G["postinstall.sh"] -->|Configure| H["Settings + Symlinks"]
  I["etherpad.service"] -->|Hardened unit| J["systemd service"]
  K["CI workflow"] -->|Tag trigger| L["Build + Test + Release"]
Loading

Grey Divider

File Changes

1. packaging/scripts/preinstall.sh ⚙️ Configuration changes +28/-0

Create etherpad system user and group

packaging/scripts/preinstall.sh


2. packaging/scripts/postinstall.sh ⚙️ Configuration changes +71/-0

Configure directories, rewrite DB default to sqlite

packaging/scripts/postinstall.sh


3. packaging/scripts/preremove.sh ⚙️ Configuration changes +20/-0

Stop systemd service before package removal

packaging/scripts/preremove.sh


View more (7)
4. packaging/scripts/postremove.sh ⚙️ Configuration changes +43/-0

Clean up config, data, and system user on purge

packaging/scripts/postremove.sh


5. packaging/nfpm.yaml ⚙️ Configuration changes +120/-0

Define nfpm manifest for .deb package structure

packaging/nfpm.yaml


6. packaging/bin/etherpad ✨ Enhancement +18/-0

CLI wrapper launching Etherpad via tsx ESM loader

packaging/bin/etherpad


7. packaging/systemd/etherpad.service ⚙️ Configuration changes +48/-0

Hardened systemd unit with security restrictions

packaging/systemd/etherpad.service


8. packaging/systemd/etherpad.default ⚙️ Configuration changes +7/-0

Environment variable defaults for systemd service

packaging/systemd/etherpad.default


9. packaging/README.md 📝 Documentation +95/-0

Document packaging layout, build, install, and upgrade

packaging/README.md


10. .github/workflows/deb-package.yml ⚙️ Configuration changes +164/-0

CI workflow to build, test, and release .deb packages

.github/workflows/deb-package.yml


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 22, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Readonly /opt breaks startup 🐞 Bug ☼ Reliability
Description
The unit sets ProtectSystem=strict but Etherpad writes runtime files under settings.root/var during
startup (for example var/js and var/installed_plugins.json). Because /opt/etherpad/var is neither
created/redirected in postinstall nor included in ReadWritePaths, a fresh install can fail to start
with mkdir/write errors under /opt/etherpad/var.
Code

packaging/systemd/etherpad.service[R22-44]

+# --- Sandboxing ---------------------------------------------------------
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectKernelLogs=true
+ProtectControlGroups=true
+ProtectHostname=true
+ProtectClock=true
+RestrictRealtime=true
+RestrictSUIDSGID=true
+RestrictNamespaces=true
+LockPersonality=true
+MemoryDenyWriteExecute=false     # Node's JIT needs W+X mappings
+SystemCallArchitectures=native
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
+UMask=0027
+
+ReadWritePaths=/var/lib/etherpad /var/log/etherpad /etc/etherpad
+
Evidence
The service sandbox makes the filesystem read-only except for explicit exceptions, but Etherpad’s
startup path writes to <root>/var. With the Debian layout, Etherpad’s computed root is the directory
above src (so /opt/etherpad), making those writes target /opt/etherpad/var which is not writable per
the unit and not redirected by the packaging scripts.

packaging/systemd/etherpad.service[22-44]
packaging/scripts/postinstall.sh[34-40]
src/node/utils/AbsolutePaths.ts[77-93]
src/node/server.ts[173-175]
src/static/js/pluginfw/installer.ts[25-29]
src/static/js/pluginfw/installer.ts[136-148]
src/node/hooks/express/specialpages.ts[310-314]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The systemd unit makes `/opt/etherpad` effectively read-only (`ProtectSystem=strict` + `ReadWritePaths` omits `/opt/etherpad/...`), but Etherpad writes to `path.join(settings.root, 'var', ...)` during startup, where `settings.root` resolves to `/opt/etherpad` in this package layout. This can prevent `etherpad.service` from starting.

### Issue Context
Etherpad startup awaits `checkForMigration()` which can write `${root}/var/installed_plugins.json`, and the express hooks create `${root}/var/js`. The package currently does not create or redirect `/opt/etherpad/var` to a writable location.

### Fix Focus Areas
- packaging/scripts/postinstall.sh[34-40]
- packaging/systemd/etherpad.service[22-44]

### Suggested fix
Implement one of the following (prefer the symlink approach to keep writes out of `/opt`):
1) **Symlink `/opt/etherpad/var` to a writable location**:
  - In `postinstall.sh` (configure):
    - `mkdir -p /var/lib/etherpad/var`
    - `ln -sfn /var/lib/etherpad/var /opt/etherpad/var`
    - `chown -R etherpad:etherpad /var/lib/etherpad/var`
  - This keeps `ReadWritePaths=/var/lib/etherpad` sufficient.

2) **Allow `/opt/etherpad/var` writes explicitly**:
  - Create `/opt/etherpad/var` in `postinstall.sh` and `chown` it to `etherpad:etherpad`.
  - Add `/opt/etherpad/var` to `ReadWritePaths` in the unit.

Either way, ensure the directory exists before service start so `fs.mkdirSync(.../var/js)` and plugin migration writes don’t throw.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. nfpm download not verified 🐞 Bug ⛨ Security
Description
The release workflow downloads an nfpm .deb via curl and installs it with dpkg without verifying a
checksum or signature. If the upstream artifact is tampered with, the workflow could produce
compromised release packages.
Code

.github/workflows/deb-package.yml[R64-71]

+      - name: Install nfpm
+        run: |
+          set -e
+          NFPM_ARCH=amd64
+          [ "${{ matrix.arch }}" = "arm64" ] && NFPM_ARCH=arm64
+          curl -fsSL -o /tmp/nfpm.deb \
+            "https://github.com/goreleaser/nfpm/releases/download/${NFPM_VERSION}/nfpm_${NFPM_VERSION#v}_${NFPM_ARCH}.deb"
+          sudo dpkg -i /tmp/nfpm.deb
Evidence
The workflow directly installs a remotely fetched .deb into the runner without any integrity
verification step (checksum/GPG).

.github/workflows/deb-package.yml[64-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The workflow installs nfpm from a downloaded `.deb` without any integrity verification.

### Issue Context
Even with HTTPS, this lacks defense-in-depth against compromised upstream release artifacts.

### Fix Focus Areas
- .github/workflows/deb-package.yml[64-71]

### Suggested fix
Add an integrity verification step before `dpkg -i`, for example:
- Download nfpm’s published `checksums.txt` (or equivalent) for `${NFPM_VERSION}` and verify `/tmp/nfpm.deb` with `sha256sum -c`.
- Alternatively, verify a published signature (GPG/cosign) if available for nfpm release artifacts.
- Only proceed to `sudo dpkg -i` if verification passes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +22 to +44
# --- Sandboxing ---------------------------------------------------------
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectHostname=true
ProtectClock=true
RestrictRealtime=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=false # Node's JIT needs W+X mappings
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
UMask=0027

ReadWritePaths=/var/lib/etherpad /var/log/etherpad /etc/etherpad

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Readonly /opt breaks startup 🐞 Bug ☼ Reliability

The unit sets ProtectSystem=strict but Etherpad writes runtime files under settings.root/var during
startup (for example var/js and var/installed_plugins.json). Because /opt/etherpad/var is neither
created/redirected in postinstall nor included in ReadWritePaths, a fresh install can fail to start
with mkdir/write errors under /opt/etherpad/var.
Agent Prompt
### Issue description
The systemd unit makes `/opt/etherpad` effectively read-only (`ProtectSystem=strict` + `ReadWritePaths` omits `/opt/etherpad/...`), but Etherpad writes to `path.join(settings.root, 'var', ...)` during startup, where `settings.root` resolves to `/opt/etherpad` in this package layout. This can prevent `etherpad.service` from starting.

### Issue Context
Etherpad startup awaits `checkForMigration()` which can write `${root}/var/installed_plugins.json`, and the express hooks create `${root}/var/js`. The package currently does not create or redirect `/opt/etherpad/var` to a writable location.

### Fix Focus Areas
- packaging/scripts/postinstall.sh[34-40]
- packaging/systemd/etherpad.service[22-44]

### Suggested fix
Implement one of the following (prefer the symlink approach to keep writes out of `/opt`):
1) **Symlink `/opt/etherpad/var` to a writable location**:
   - In `postinstall.sh` (configure):
     - `mkdir -p /var/lib/etherpad/var`
     - `ln -sfn /var/lib/etherpad/var /opt/etherpad/var`
     - `chown -R etherpad:etherpad /var/lib/etherpad/var`
   - This keeps `ReadWritePaths=/var/lib/etherpad` sufficient.

2) **Allow `/opt/etherpad/var` writes explicitly**:
   - Create `/opt/etherpad/var` in `postinstall.sh` and `chown` it to `etherpad:etherpad`.
   - Add `/opt/etherpad/var` to `ReadWritePaths` in the unit.

Either way, ensure the directory exists before service start so `fs.mkdirSync(.../var/js)` and plugin migration writes don’t throw.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread .github/workflows/deb-package.yml
Comment thread .github/workflows/deb-package.yml Outdated
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current lts is 24

Comment thread packaging/README.md Outdated

## Building locally

Prereqs: Node 22, pnpm 10+, nfpm.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node 24 makes more sense I think

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I follow Etherpad's own engine.node - if we want to bump node version we can, but this isn't the place to begin that bump IMHO.

Comment thread packaging/README.md Outdated
## Installing

```sh
sudo apt install ./dist/etherpad_2.6.1_amd64.deb
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to make this more generic? This is already out of date.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

etherpad-latest.deb?

…de LTS

Addresses Qodo and SamTV12345 review feedback on ether#7583:

- postinstall: symlink /opt/etherpad/var → /var/lib/etherpad/var so
  ProtectSystem=strict doesn't block runtime writes (var/js,
  installed_plugins.json, etc.). Existing ReadWritePaths covers it.
- postinstall: seed installed_plugins.json with ep_etherpad-lite so
  checkForMigration() does not spawn `pnpm ls` on first boot — pnpm is
  not a runtime dep, and the bundled node_modules already contains
  every shipped plugin. Prevents network plugin installs at first run.
- postremove: clean up the new var symlink on remove.
- workflow: verify nfpm .deb sha256 against upstream checksums.txt
  before sudo dpkg -i (defense in depth).
- workflow: bump Node 22 → 24 (current LTS, per SamTV12345). The deb
  Depends stays at nodejs (>= 20) to match Etherpad's engines.node.
- workflow: smoke-test now asserts the var symlink and seeded
  installed_plugins.json exist post-install.
- workflow: publish stable etherpad-latest_{amd64,arm64}.deb aliases
  alongside the versioned files in the GitHub Release.
- README: bump Node guidance to 24, document /releases/latest URL,
  link to engines.node floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit 725e73d

Comment thread packaging/bin/etherpad Outdated
Comment thread packaging/systemd/etherpad.service
Comment thread .github/workflows/deb-package.yml Outdated
Addresses second-round Qodo review on ether#7583:

- bin/etherpad: switch from `--import tsx/.../esm` to `--require
  tsx/cjs`. server.ts uses `exports.start = ...` which throws under
  the ESM loader; the prod script in src/package.json uses tsx/cjs
  for the same reason.
- postinstall: symlink /opt/etherpad/src/plugin_packages →
  /var/lib/etherpad/plugin_packages and chgrp /opt/etherpad/src/node_modules
  to etherpad with mode 2775. Otherwise admin-UI plugin install
  EACCESes — those are the dirs LinkInstaller writes to.
- systemd unit: add /opt/etherpad/src/node_modules to ReadWritePaths
  so symlink creation by the etherpad user is allowed under
  ProtectSystem=strict. plugin_packages is already covered via the
  symlink into /var/lib/etherpad.
- postremove: clean up the new plugin_packages symlink on remove.
- workflow: tag filters were `v[0-9]+.[0-9]+.[0-9]+`, but Actions tag
  filters are globs, not regex. `[0-9]+` matches one character, so
  multi-digit tags like v2.10.0 would never trigger. Switch to
  `v*.*.*` / `v*.*.*-*`, matching handleRelease.yml.
- workflow smoke test now asserts plugin_packages symlink target,
  ownership of plugin_packages and node_modules.
- test-local.sh: new script that builds the .deb and runs the same
  smoke test in a throwaway systemd-enabled Docker container, so
  failures are caught before pushing.
- README: document test-local.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit d1f51f3

Comment on lines +6 to +18
case "$1" in
install|upgrade)
if ! getent group etherpad >/dev/null 2>&1; then
addgroup --system etherpad
fi
if ! getent passwd etherpad >/dev/null 2>&1; then
adduser --system --ingroup etherpad \
--home /var/lib/etherpad \
--no-create-home \
--shell /usr/sbin/nologin \
--gecos "Etherpad service user" \
etherpad
fi
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Preinst depends not guaranteed 🐞 Bug ☼ Reliability

The preinstall maintainer script requires addgroup/adduser, but the package only declares adduser as
a regular dependency, so dpkg -i on a minimal system can fail before unpacking if adduser isn’t
already installed.
Agent Prompt
### Issue description
`preinstall.sh` calls `addgroup`/`adduser` during the `preinst` phase, but `adduser` is only in `Depends`. Direct installs via `dpkg -i` can run `preinst` before `adduser` is present, causing installation to abort.

### Issue Context
This is especially problematic because a `preinst` failure happens *before* unpacking, so follow-up dependency repair (`apt-get -f install`) may not be able to recover.

### Fix Focus Areas
Implement one of:
- **Debian-correct dependency**: add a Debian `Pre-Depends: adduser` (if nfpm supports it) so `adduser` is guaranteed before `preinst`.
- **Move user/group creation later**: shift user/group creation to `postinstall` (`configure`), and adjust packaging so unpack-time ownership does not require the `etherpad` group to already exist (e.g., set `/etc/default/etherpad` group to root in the payload and `chgrp` it in `postinstall`).

- packaging/scripts/preinstall.sh[1-18]
- packaging/nfpm.yaml[22-26]
- packaging/scripts/postinstall.sh[16-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

JohnMcLear and others added 4 commits April 27, 2026 06:33
- systemd-in-docker on cgroups v2 needs --cgroupns=host and a writable
  /sys/fs/cgroup mount; the previous :ro version booted to nothing.
- New --no-systemd mode: drops the systemd container in favour of plain
  ubuntu:24.04 + manual launch under the etherpad user. Validates the
  postinstall, wrapper, plugin paths, and /health without depending on
  the host's systemd-in-docker setup. Use it when --privileged systemd
  containers don't boot on your kernel/docker combo.
- On systemd container exit the script now dumps the last 50 log lines
  and points at --no-systemd as the fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If ubuntu:24.04 isn't on disk and the registry is unreachable, fall
back to whichever ubuntu/debian image is already cached (e.g. the
jrei/systemd-ubuntu image we pulled for the systemd path). Avoids a
registry round-trip on flaky networks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ine-safe test

src/node/utils/run_cmd.ts:
  Without `proc.on('error', ...)` a spawn failure (e.g. ENOENT for a
  missing binary) is emitted as an unlistened 'error' event, which
  Node treats as an uncaught exception that bypasses the awaiting
  try/catch and kills the process. The .deb hits this on first boot
  because plugins.ts spawns `pnpm --version` for a startup log line
  and pnpm isn't a runtime dep — Etherpad logs "Starting" then
  immediately stops. Reject the promise on 'error' so the existing
  try/catch in the caller actually catches it.

packaging/scripts/postinstall.sh:
  chown /var/lib/etherpad/plugin_packages AFTER `cp -a` from the
  staged tree — `cp -a` preserves source (root) ownership and was
  re-rooting the directory we'd just chowned to etherpad. Same
  ordering the var symlink block already used.

packaging/test-local.sh:
  Run `CI=1 pnpm install --frozen-lockfile` before staging so the
  package is built from a fresh, lockfile-consistent tree (matches
  CI). Fixes spurious "Cannot find module 'X'" failures from stale
  local symlinks pointing at out-of-date pnpm store paths.

End-to-end test now passes: postinstall asserts pass, /health
returns 200, dpkg --purge cleans up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop packaging/etc/settings.json.dist that snuck into the previous
commit (generated at build time by test-local.sh / CI from
settings.json.template). Add /staging/, /dist/, /packaging/etc/ to
.gitignore so they don't recur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit f2f0259

The startup IIFE that logs the pnpm version is informational only.
pnpm is a dev-only dependency: admin-UI plugin install goes through
live-plugin-manager directly, and plugin migration is short-circuited
when var/installed_plugins.json is present (e.g. on packaged
installs). A missing pnpm on PATH is therefore expected on hardened
deployments and shouldn't surface as a red ERROR in journalctl.

Detect ENOENT specifically and log at debug; treat other errors
(permission denied, etc.) as warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/node/utils/run_cmd.ts
Comment on lines +149 to +159
// Without this, a spawn failure (e.g. ENOENT for a missing binary) is
// emitted as an 'error' event with no listener, which Node.js treats as
// an uncaught exception that bypasses any try/catch around the awaited
// promise and kills the process. Reject the promise instead so callers
// can handle it.
proc.on('error', (err) => {
procFailedErr.message = `Failed to spawn ${args[0]}: ${(err as Error).message}`;
procFailedErr.code = (err as any).code;
logger.debug(procFailedErr.stack);
px.reject(procFailedErr);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. run_cmd spawn error untested 📘 Rule violation ☼ Reliability

The PR fixes run_cmd to reject on spawn errors (e.g. missing binary), but it does not add a
regression test to ensure this behavior doesn’t silently regress. Without an automated test, the
original process-killing failure mode could be reintroduced unnoticed.
Agent Prompt
## Issue description
A bug fix was added to `src/node/utils/run_cmd.ts` to handle `spawn` failures by rejecting the returned promise, but there is no regression test that would fail if this handler were removed.

## Issue Context
The new code handles the `ChildProcess` `error` event (e.g. `ENOENT`) and calls `px.reject(...)` instead of letting Node treat the unhandled `error` event as an uncaught exception that kills the process. Add a `vitest` test that calls `run_cmd()` with a definitely-missing binary name and asserts it rejects with an error that includes `code === 'ENOENT'` (or equivalent), ensuring the test suite would fail/crash if the handler were reverted.

## Fix Focus Areas
- src/node/utils/run_cmd.ts[149-159]
- src/tests/backend-new/specs/[add new test file exercising spawn ENOENT]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

…rors

CI gap: deb-package.yml only fired on v* tag pushes, so a PR that
broke the .deb wasn't caught until release time. Wire it to PRs and
develop pushes via a paths filter covering packaging files and the
runtime files Etherpad needs at first boot. The release job already
gates on `if: startsWith(github.ref, 'refs/tags/v')` so PR runs
won't try to publish.

Test gap: the run_cmd.ts spawn-error fix (commit 5eee789) had no
test, which is how the bug shipped originally — plugins.ts spawned
`pnpm --version` at startup, the rejection was never caught, and
the .deb crashed mid-boot. Add a backend spec that exercises:
  - ENOENT for a missing binary -> rejects (regression test)
  - successful command -> resolves stdout
  - non-zero exit -> rejects with code

backend-tests.yml's recursive mocha glob picks up the new spec
automatically; no workflow change needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit b73c8cf

Comment on lines +136 to +139
set -eux
sudo apt-get update
sudo apt-get install -y nodejs
sudo dpkg -i dist/*.deb || sudo apt-get install -f -y
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Smoke test doesn't ensure node>=20 🐞 Bug ☼ Reliability

The deb-package workflow installs nodejs from the runner’s default APT sources without ensuring it
meets the package dependency nodejs (>= 20), so dpkg -i can fail to configure (or behave
differently) depending on the runner image. This makes the smoke-test nondeterministic and can
invalidate the subsequent assertions/startup test.
Agent Prompt
### Issue description
The CI smoke test installs `nodejs` from default APT sources without ensuring it satisfies the package dependency (`nodejs (>= 20)`), making the smoke test nondeterministic and potentially invalid.

### Issue Context
- The `.deb` declares `Depends: nodejs (>= 20)`.
- The workflow currently does `apt-get install -y nodejs` with no repo setup/pinning.
- `packaging/test-local.sh` already uses NodeSource (`setup_lts.x`) before installing nodejs.

### Fix Focus Areas
- .github/workflows/deb-package.yml[136-139]

### Suggested fix
In the smoke-test step, install Node.js via a deterministic mechanism, for example:
- Add NodeSource LTS repo (`curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -`) then `sudo apt-get install -y nodejs`, **or**
- Use a pinned distro/repo that guarantees `nodejs >= 20`.

This should happen before `dpkg -i dist/*.deb` so the package can configure and `postinstall` assertions are meaningful.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

…s node 18)

ubuntu-latest's default apt nodejs is 18.19.1, but our package requires
nodejs (>= 20). The smoke test was doing `apt-get install nodejs`
followed by `dpkg -i ... || apt-get install -f`, which on a node-18
host fails the dep check, then `-f` "fixes" by REMOVING the etherpad
package — and the next assertion (test -x /usr/bin/etherpad) crashes.

Match what packaging/test-local.sh and the README recommend: install
node from NodeSource (current LTS) before installing the .deb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit 0c9c357

Comment thread packaging/scripts/postinstall.sh Outdated
Comment on lines +63 to +90
chown -R etherpad:etherpad "${RUNTIME_VAR}"

# Plugin install paths. Etherpad's admin UI installs plugins into
# ${root}/src/plugin_packages and creates symlinks under
# ${root}/src/node_modules. Both are under /opt and would EACCES
# under the etherpad user without these adjustments.
PLUGIN_PKG_LIVE=/var/lib/etherpad/plugin_packages
PLUGIN_PKG_LINK="${APP_DIR}/src/plugin_packages"
NODE_MODULES_DIR="${APP_DIR}/src/node_modules"

mkdir -p "${PLUGIN_PKG_LIVE}"
if [ -e "${PLUGIN_PKG_LINK}" ] && [ ! -L "${PLUGIN_PKG_LINK}" ]; then
cp -a "${PLUGIN_PKG_LINK}/." "${PLUGIN_PKG_LIVE}/" 2>/dev/null || true
rm -rf "${PLUGIN_PKG_LINK}"
fi
# chown after the cp -- cp -a preserves the (root) ownership of the
# staged source files and would re-root anything we chowned earlier.
chown -R etherpad:etherpad "${PLUGIN_PKG_LIVE}"
ln -sfn "${PLUGIN_PKG_LIVE}" "${PLUGIN_PKG_LINK}"

# node_modules is bundled (root-owned contents); the directory itself
# must be group-writable by etherpad so plugin installs can create
# symlinks alongside the shipped packages. ReadWritePaths in the unit
# also exposes it as writable under ProtectSystem=strict.
if [ -d "${NODE_MODULES_DIR}" ]; then
chgrp etherpad "${NODE_MODULES_DIR}"
chmod 2775 "${NODE_MODULES_DIR}"
fi
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Symlink chown escalation 🐞 Bug ⛨ Security

packaging/scripts/postinstall.sh recursively chown -R's /var/lib/etherpad/var and
/var/lib/etherpad/plugin_packages after making them writable by the etherpad user, so a symlink
planted inside these trees can redirect root’s chown onto arbitrary paths during an upgrade. This
is a root privilege escalation vector triggered by dpkg running maintainer scripts as root.
Agent Prompt
### Issue description
`postinstall.sh` runs `chown -R` on directories that are writable by the `etherpad` service user. If the `etherpad` user (or an attacker who gained that user) plants symlinks inside those directories, a future package upgrade can cause the root-run postinstall to `chown` arbitrary files/directories (symlink attack).

### Issue Context
This runs under `dpkg` as root on upgrades (`postinst configure`). The writable directories are `/var/lib/etherpad/var` and `/var/lib/etherpad/plugin_packages`.

### Fix Focus Areas
- packaging/scripts/postinstall.sh[63-90]

### Suggested fix
- Replace recursive `chown -R` calls with a symlink-safe alternative, such as:
  - `chown -hR etherpad:etherpad "$RUNTIME_VAR"` and `chown -hR etherpad:etherpad "$PLUGIN_PKG_LIVE"` (GNU coreutils), and/or
  - `find -P "$dir" -xdev -exec chown etherpad:etherpad {} +` to ensure symlinks are not dereferenced.
- Keep ownership correction behavior for normal files/dirs, but ensure symlinks cannot cause traversal or ownership changes outside the intended trees.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

postinstall sets /etc/etherpad to 0750 root:etherpad (DB creds live
here) and /var/lib/etherpad similarly. The GH Actions runner user
isn't in the etherpad group, so 'test -f /etc/etherpad/settings.json'
hits EACCES. Add sudo to each check that crosses one of those dirs.

(Wrapping the whole block in `sudo bash <<EOF` would have been
cleaner but YAML literal-block + heredoc terminator don't play well
together at this indent.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit 3daf300

Comment thread .github/workflows/deb-package.yml Outdated
…adduser

postinstall:
  Use `chown -hR` instead of `chown -R` on /var/lib/etherpad/var and
  /var/lib/etherpad/plugin_packages. Both directories are writable by
  the unprivileged etherpad service user, so a symlink planted there
  could redirect root's chown onto arbitrary system files (e.g.
  /etc/shadow) on the next `apt upgrade`. -hR makes chown act on the
  symlink itself rather than its target — standard mitigation for this
  TOCTOU-style local privilege escalation.

nfpm:
  Move adduser from Depends to Pre-Depends. preinst creates the
  etherpad user before unpacking; with plain `dpkg -i` (no apt) the
  Depends list isn't installed beforehand, so a minimal system without
  adduser would fail preinst before unpack and apt-get -f couldn't
  recover. Pre-Depends guarantees adduser is configured first.

Both flagged in Qodo's persistent review of 3daf300.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit 7daaf07

nfpm's Overridables schema doesn't include predepends; it's a deb-only
top-level field. Previous commit nested it under overrides.deb, which
caused nfpm to reject the entire manifest with "field predepends not
found in type nfpm.Overridables" and broke both arch builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

…l, disable on remove, writable settings)

deb-package.yml:
  - Move 'Resolve version' (which calls `node -p`) to AFTER setup-node
    so it doesn't depend on the runner image preinstalling node.
  - Replace `curl ... | sudo bash` NodeSource installer with the
    explicit gpg-key + sources.list approach. Same outcome (NodeSource
    LTS apt repo), but no execution of network-fetched code as root.
    Reduces blast radius if NodeSource's setup endpoint is ever
    compromised — we only trust the signed apt repo metadata.

postinstall.sh:
  - /etc/etherpad/settings.json now etherpad:etherpad mode 0660 (was
    root:etherpad 0640). The admin /admin/settings UI persists changes
    by writing back to settings.settingsFilename; with the previous
    perms the etherpad user could read but not write, so saving via
    the admin UI failed silently. Group-only access preserved (DB
    creds still unreadable by other users).

postremove.sh:
  - On `dpkg --remove`, run `systemctl disable etherpad.service` before
    `daemon-reload` so the wants/ symlink doesn't dangle after dpkg
    deletes the unit file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit 1173fcb

…ERSION

deb-package.yml:
  Workflow-level permissions was `contents: write` so the build job got
  write access on every PR run, even though only the release job needs
  it (to attach release assets). Narrow the workflow default to
  `contents: read` and let the release job opt back in to write — it
  already declares its own job-level `contents: write` block, so this
  is just removing an over-broad default.

test-local.sh:
  The script defined NFPM_VERSION but then unconditionally ran
  `goreleaser/nfpm:latest`, so local builds could diverge from CI's
  pinned v2.43.0. Use the variable in the docker tag (stripping the
  leading "v" to match the image's tag scheme).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Member Author

/review

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 27, 2026

Persistent review updated to latest commit 287497b

@JohnMcLear JohnMcLear merged commit 0b40bfc into ether:develop Apr 27, 2026
19 checks passed
Comment on lines +40 to +42
# Etherpad reads settings.json from CWD (/opt/etherpad). Expose
# the /etc copy there via symlink.
ln -sfn "${ACTIVE_SETTINGS}" "${APP_DIR}/settings.json"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Settings.json clobbered 🐞 Bug ☼ Reliability

packaging/scripts/postinstall.sh unconditionally force-replaces /opt/etherpad/settings.json with a
symlink to /etc/etherpad/settings.json, deleting any pre-existing non-symlink settings.json. This
can silently wipe an existing installation’s configuration (DB/auth/etc) when transitioning to the
.deb, potentially preventing correct startup.
Agent Prompt
### Issue description
The postinstall script forcefully replaces `/opt/etherpad/settings.json` with a symlink to `/etc/etherpad/settings.json`. If a user already has a real (non-symlink) `settings.json` at `/opt/etherpad/settings.json` (common for manual installs / legacy packaging), it will be deleted, losing configuration.

### Issue Context
Etherpad defaults to loading `settings.json` from the Etherpad root directory. The repo docs and legacy init config reference editing `/opt/etherpad/settings.json`, so it’s realistic that this file exists before installing the new .deb.

### Fix Focus Areas
- packaging/scripts/postinstall.sh[23-43]

### Suggested fix
Before `ln -sfn ... /opt/etherpad/settings.json`, add migration/backup logic:
- If `${APP_DIR}/settings.json` exists and is **not** a symlink:
  - If `${ACTIVE_SETTINGS}` does **not** exist yet, copy/move the existing `${APP_DIR}/settings.json` to `${ACTIVE_SETTINGS}` (preserving ownership/permissions appropriately), then proceed to symlink.
  - Else (both exist), keep `/etc` as source of truth but **back up** the old `${APP_DIR}/settings.json` to something like `${APP_DIR}/settings.json.bak.<timestamp>` and log a message to stdout/stderr.
- Only then run `ln -sfn`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants